Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

10장. Enum과 Sealed Type

프로그램에는 “정해진 몇 가지 중 하나“인 값이 자주 등장합니다.

주문의 상태는 “결제 대기, 결제 완료, 배송 중, 배송 완료” 중 하나입니다.
요일은 “월, 화, 수, 목, 금, 토, 일” 중 하나입니다.
이렇게 값의 종류가 미리 정해져 있는 경우가 많습니다.

이런 값을 문자열이나 숫자로 대충 표현하면
오타나 잘못된 값이 슬그머니 끼어들기 쉽습니다.

이 장에서는 “정해진 종류“를 안전하게 표현하는 두 도구,
Enum과 Sealed Type을 배웁니다.

그리고 성공과 실패 같은 “결과“를
타입으로 깔끔하게 표현하는 방법까지 함께 살펴봅니다.


10.1 Enum: 상태를 Enum으로 표현하기

상태를 문자열로 표현하면 생기는 문제

먼저 Enum이 왜 필요한지부터 느껴 봅시다.

주문 상태를 문자열로 표현한다고 해 보겠습니다.

var status = "PAID"        // 결제 완료
status = "SHIPPING"        // 배송 중

언뜻 보면 문제가 없어 보입니다.
하지만 이 방식에는 위험이 숨어 있습니다.

status = "Paid"            // 대소문자가 다름
status = "결제완료"          // 표기가 제각각
status = "PADI"            // 오타

이 코드는 전부 “정상적으로 실행“됩니다.
컴파일러가 잘못을 잡아 주지 못하기 때문입니다.

문자열은 어떤 글자든 담을 수 있으므로
“허용된 값“이라는 개념 자체가 없습니다.

즉, 상태의 종류가 정해져 있는데도
문자열은 그 약속을 강제해 주지 못합니다.

Enum이란 무엇인가

이럴 때 쓰는 것이 Enum입니다.

Enum은 enumeration(열거)의 줄임말로,
우리말로는 “열거형“이라고 부릅니다.

이름은 어렵지만 뜻은 간단합니다.

Enum은 “정해진 값들의 목록“을
하나의 타입으로 만든 것입니다.

비유하자면 이렇습니다.

문자열이 “아무 글자나 쓸 수 있는 빈칸“이라면,
Enum은 “정답이 미리 적힌 객관식 보기“입니다.

보기에 없는 값은 애초에 고를 수 없습니다.

코틀린에서는 enum class로 만듭니다.

enum class OrderStatus {
    PENDING,     // 결제 대기
    PAID,        // 결제 완료
    SHIPPING,    // 배송 중
    DELIVERED    // 배송 완료
}

이제 주문 상태는 이 네 가지 중 하나로만
표현할 수 있습니다.

각각의 값(PENDING, PAID 등)을
Enum 상수(enum constant)라고 부릅니다.

Enum을 사용하면 안전해지는 이유

Enum으로 만든 값은 이렇게 씁니다.

var status = OrderStatus.PAID
status = OrderStatus.SHIPPING

여기서 목록에 없는 값을 넣어 보겠습니다.

status = OrderStatus.PADI    // 오류: 그런 값은 없음

이 코드는 실행되지 않습니다.
컴파일 단계에서 오류로 걸러집니다.

문자열이었다면 그냥 넘어갔을 오타를,
Enum은 프로그램 실행 전에 막아 줍니다.

정리하면 이렇습니다.

방식잘못된 값오타 방지자동 완성
문자열가능안 됨안 됨
Enum불가능

Enum을 쓰면 IntelliJ가
가능한 값을 자동으로 보여 주기까지 합니다.

값을 외울 필요도, 오타를 걱정할 필요도 없습니다.

Enum Property와 Method

Enum은 단순한 값의 목록에서 그치지 않습니다.
각 값에 추가 정보를 함께 담을 수 있습니다.

이 추가 정보를 프로퍼티(property)라고 합니다.
프로퍼티는 “그 값이 가진 속성“이라고 생각하면 됩니다.
(프로퍼티는 클래스와 함께 4부에서 자세히 다룹니다.)

예를 들어 각 상태마다
“사용자에게 보여 줄 한글 이름“을 붙여 보겠습니다.

enum class OrderStatus(val label: String) {
    PENDING("결제 대기"),
    PAID("결제 완료"),
    SHIPPING("배송 중"),
    DELIVERED("배송 완료")
}

이제 각 상태에서 한글 이름을 꺼낼 수 있습니다.

val status = OrderStatus.PAID
println(status.label)
결제 완료

Enum 안에는 메서드(method)도 넣을 수 있습니다.
메서드는 “그 값이 할 수 있는 일“입니다.
쉽게 말해 Enum 안에 정의한 함수입니다.

예를 들어 “이 상태에서 취소가 가능한가“를
알려 주는 메서드를 만들어 보겠습니다.

enum class OrderStatus(val label: String) {
    PENDING("결제 대기"),
    PAID("결제 완료"),
    SHIPPING("배송 중"),
    DELIVERED("배송 완료");

    fun canCancel(): Boolean {
        return this == PENDING || this == PAID
    }
}

여기서 한 가지 규칙이 있습니다.
상수 목록 뒤에 메서드를 이어 쓸 때는
목록 끝에 세미콜론(;)을 붙여야 합니다.

코틀린에서 세미콜론이 필요한
드문 경우 중 하나입니다.

사용하는 모습은 이렇습니다.

println(OrderStatus.PAID.canCancel())        // true
println(OrderStatus.SHIPPING.canCancel())    // false

Enum이 기본으로 제공하는 기능

Enum은 몇 가지 편리한 기능을
자동으로 갖추고 있습니다.

자주 쓰는 것만 정리하면 이렇습니다.

기능설명
name상수의 이름을 문자열로 반환
ordinal상수의 순서(0부터 시작)를 반환
values()모든 상수를 배열로 반환
valueOf("이름")이름에 해당하는 상수를 반환

간단히 확인해 보겠습니다.

val status = OrderStatus.PAID

println(status.name)        // PAID
println(status.ordinal)     // 1

for (s in OrderStatus.values()) {
    println(s.label)
}
PAID
1
결제 대기
결제 완료
배송 중
배송 완료

이렇게 Enum 하나로
“정해진 값 + 관련 정보 + 관련 동작“을
한곳에 깔끔하게 모을 수 있습니다.


10.2 Sealed Class: 제한된 타입 계층

Enum만으로는 부족한 순간

Enum은 아주 편리하지만 한계가 있습니다.
값마다 “서로 다른 모양의 데이터“를 담기 어렵습니다.

예를 들어 결제 결과를 생각해 봅시다.

  • 성공했다면: 결제 승인 번호가 필요합니다
  • 실패했다면: 실패 사유가 필요합니다
  • 처리 중이라면: 아무 정보도 필요 없습니다

성공과 실패가 담아야 할 정보의 모양이
서로 다릅니다.

Enum은 모든 상수가 같은 프로퍼티를 공유하므로
이런 “제각각 다른 데이터“를 표현하기 불편합니다.

Sealed Class란 무엇인가

이럴 때 쓰는 것이 Sealed Class입니다.

sealed는 “봉인된, 밀봉된“이라는 뜻입니다.
우리말로는 “봉인 클래스“라고 부릅니다.

뜻을 풀어 보면 이렇습니다.

Sealed Class는
“정해진 자식들만 가질 수 있는,
봉인된 타입“입니다.

비유하자면 이렇습니다.

Enum이 “정해진 값 목록“이라면,
Sealed Class는 “정해진 종류 목록“입니다.

각 종류가 저마다 다른 데이터를
담을 수 있다는 점이 큰 차이입니다.

여기서 클래스(class)와 상속(inheritance)이
등장하는데, 이 개념은 4부에서 자세히 배웁니다.
지금은 아래 그림 정도로만 이해하면 충분합니다.

부모 타입 하나가 있고,
그 아래에 “미리 정해진 자식 타입“만
존재할 수 있다.

결제 결과를 Sealed Class로 만들어 보겠습니다.

sealed class PaymentResult {
    data class Success(val approvalNo: String) : PaymentResult()
    data class Failure(val reason: String) : PaymentResult()
    object Pending : PaymentResult()
}

한 줄씩 뜯어보겠습니다.

  • sealed class PaymentResult : 봉인된 부모 타입
  • Success : 성공, 승인 번호를 담음
  • Failure : 실패, 사유를 담음
  • Pending : 처리 중, 담을 데이터가 없음

data class는 데이터를 담기 좋은 클래스이고,
object는 데이터가 없는 단 하나뿐인 값입니다.
(둘 다 4부에서 자세히 다룹니다.)

핵심은 이렇습니다.

PaymentResult의 종류는
이 세 가지가 전부다.
다른 종류는 만들 수 없다.

이것이 “봉인“의 의미입니다.

값 만들고 데이터 꺼내기

각 종류는 이렇게 만듭니다.

val r1 = PaymentResult.Success("APV-12345")
val r2 = PaymentResult.Failure("잔액 부족")
val r3 = PaymentResult.Pending

성공에는 승인 번호가,
실패에는 사유가 함께 담겨 있습니다.

Enum과 달리 종류마다
다른 데이터를 가질 수 있다는 점이 보입니다.

when과 Exhaustive Check

Sealed Class의 진짜 힘은
when과 함께 쓸 때 나옵니다.

when은 값에 따라 갈래를 나누는 문법입니다.
(when의 기본은 3장에서 다뤘습니다.)

결제 결과에 따라
다른 메시지를 만들어 보겠습니다.

fun describe(result: PaymentResult): String {
    return when (result) {
        is PaymentResult.Success -> "성공: ${result.approvalNo}"
        is PaymentResult.Failure -> "실패: ${result.reason}"
        is PaymentResult.Pending -> "처리 중입니다"
    }
}

여기서 is는 “이 타입인가?“를 확인하는 표현입니다.

그리고 각 갈래 안에서는
그 타입이 가진 데이터를 바로 꺼내 씁니다.
Success로 확인되면 approvalNo를,
Failure로 확인되면 reason을 쓸 수 있습니다.

이렇게 타입을 확인한 뒤
그 타입으로 자동 취급되는 것을
스마트 캐스트(smart cast)라고 합니다.
(스마트 캐스트는 3부에서 다시 만납니다.)

이제 가장 중요한 특징을 보겠습니다.

위 코드에는 else 갈래가 없습니다.
그런데도 오류가 나지 않습니다.

왜냐하면 PaymentResult의 종류가
세 가지뿐이라는 사실을
컴파일러가 이미 알기 때문입니다.

세 가지를 모두 처리했으니
“빠뜨린 경우가 없다“고 판단하는 것입니다.

이렇게 “모든 경우를 빠짐없이 처리했는지”
컴파일러가 확인해 주는 것을
Exhaustive Check(빠짐없음 검사)라고 합니다.

exhaustive는 “빠짐없는, 철저한“이라는 뜻입니다.

만약 한 가지를 빠뜨리면 어떻게 될까요?

fun describe(result: PaymentResult): String {
    return when (result) {
        is PaymentResult.Success -> "성공"
        is PaymentResult.Failure -> "실패"
        // Pending을 빠뜨림
    }
}
오류: 'when' expression must be exhaustive,
add necessary 'Pending' branch

컴파일러가 “Pending 갈래가 빠졌다“고
정확히 알려 줍니다.

이 기능이 왜 강력할까요?

나중에 새로운 종류를 추가하면,
그것을 처리하지 않은 모든 when
자동으로 컴파일 오류가 됩니다.

즉, 빠뜨린 곳을 컴파일러가
전부 찾아 줍니다.

문자열이나 Enum + else 방식에서는
이런 안전장치를 얻기 어렵습니다.

Enum의 when과 비교하기

Enum도 when과 함께 쓸 수 있고,
Exhaustive Check도 동작합니다.

fun labelOf(status: OrderStatus): String {
    return when (status) {
        OrderStatus.PENDING -> "결제 대기"
        OrderStatus.PAID -> "결제 완료"
        OrderStatus.SHIPPING -> "배송 중"
        OrderStatus.DELIVERED -> "배송 완료"
    }
}

Enum도 else 없이 모든 경우를 처리하면
빠짐없음 검사를 받습니다.

차이는 데이터에 있습니다.

  • Enum: 값의 목록, 모두 같은 모양
  • Sealed Class: 종류의 목록, 각각 다른 모양

“단순한 상태 값“이면 Enum,
“종류마다 다른 데이터“가 필요하면 Sealed Class.
이렇게 기억하면 쉽습니다.


10.3 Sealed Interface: 상태보다 타입 자체를 모델링하기

Sealed Interface란 무엇인가

코틀린에는 Sealed Class와 짝을 이루는
Sealed Interface도 있습니다.

인터페이스(interface)는
“이런 기능을 갖춰야 한다“는 약속입니다.
(인터페이스는 4부에서 자세히 배웁니다.)

Sealed Interface는
그 약속을 “봉인된 형태“로 만든 것입니다.

쓰는 방법과 효과는
Sealed Class와 거의 같습니다.

sealed interface PaymentResult {
    data class Success(val approvalNo: String) : PaymentResult
    data class Failure(val reason: String) : PaymentResult
    object Pending : PaymentResult
}

앞 절의 Sealed Class 예제와
거의 똑같아 보입니다.

when과 함께 쓸 때
Exhaustive Check가 되는 것도 동일합니다.

Sealed Class와 무엇이 다른가

그렇다면 언제 어느 쪽을 쓸까요?

가장 큰 차이는 이렇습니다.

클래스는 부모를 하나만 가질 수 있지만,
인터페이스는 여러 개를 동시에 구현할 수 있다.

즉, Sealed Interface를 쓰면
한 타입이 여러 봉인 계층에
동시에 속할 수 있습니다.

정리하면 이렇습니다.

구분Sealed ClassSealed Interface
공통 데이터 저장담기 좋음담을 수 없음
여러 계층에 속하기하나만 가능여러 개 가능
유연함보통더 유연함

실무에서는 이렇게 생각하면 무난합니다.

  • 모든 자식이 공유할 데이터가 있다 → Sealed Class
  • 그냥 “종류만” 나누면 된다 → Sealed Interface

특별한 이유가 없다면
가벼운 Sealed Interface를
기본으로 골라도 좋습니다.

상태가 아니라 타입을 나눈다

이 절의 제목을 다시 읽어 봅시다.
“상태보다 타입 자체를 모델링하기“입니다.

무슨 뜻인지 예로 풀어 보겠습니다.

어떤 화면이 보여 줄 수 있는 모습을
생각해 보겠습니다.

  • 데이터를 잘 불러온 화면
  • 아무것도 없는 빈 화면
  • 오류가 난 화면

이것을 Enum으로 표현하면
“상태 이름“만 남습니다.

enum class ScreenState {
    CONTENT, EMPTY, ERROR
}

하지만 실제로는 각 모습이
서로 다른 데이터를 가집니다.

  • 데이터 화면: 보여 줄 목록
  • 빈 화면: 안내 문구
  • 오류 화면: 오류 메시지

이럴 때 Sealed Interface가 잘 맞습니다.

sealed interface ScreenState {
    data class Content(val items: List<String>) : ScreenState
    data class Empty(val message: String) : ScreenState
    data class Error(val message: String) : ScreenState
}

이제 상태 “이름“만 있는 것이 아니라,
각 상태가 무엇을 담는지까지
타입 하나로 표현됩니다.

이것이 “상태보다 타입 자체를 모델링한다“의
의미입니다.

값의 이름만 나누는 것을 넘어,
“그 종류가 무엇을 담는가“까지
타입으로 그려 내는 것입니다.


10.4 결과를 타입으로 표현하기

결과를 어떻게 돌려줄까

함수는 종종 “결과“를 돌려줍니다.
그런데 결과에는 성공만 있는 것이 아닙니다.

  • 잘 처리됐다 (성공)
  • 처리에 실패했다 (실패)
  • 아직 처리 중이다 (진행 중)

이 서로 다른 결과를
하나의 함수가 어떻게 돌려줄 수 있을까요?

전통적인 방법 두 가지를 먼저 보겠습니다.

첫째, 예외(exception)를 던지는 방법입니다.
실패하면 오류를 발생시키는 방식입니다.
(예외는 8부에서 자세히 다룹니다.)

둘째, null을 돌려주는 방법입니다.
실패하면 “값 없음“을 뜻하는 null을 주는 방식입니다.
(null은 3부에서 다룹니다.)

두 방법 모두 쓰이지만 아쉬움이 있습니다.
“왜 실패했는지“를 함께 전달하기 어렵거나,
호출한 쪽이 처리를 깜빡하기 쉽습니다.

성공, 실패, 처리 중을 타입으로

이럴 때 Sealed Type이 빛을 발합니다.
결과의 종류 자체를 타입으로 만드는 것입니다.

sealed interface SignUpResult {
    data class Success(val userId: Long) : SignUpResult
    data class Failure(val message: String) : SignUpResult
    object Processing : SignUpResult
}

회원 가입 함수가
이 타입을 돌려준다고 해 보겠습니다.

fun signUp(email: String): SignUpResult {
    if (email.isBlank()) {
        return SignUpResult.Failure("이메일을 입력해 주세요")
    }
    // ... 실제 가입 처리 ...
    return SignUpResult.Success(userId = 1001)
}

호출하는 쪽은 이렇게 씁니다.

val result = signUp("test@example.com")

val message = when (result) {
    is SignUpResult.Success -> "가입 완료! id=${result.userId}"
    is SignUpResult.Failure -> "가입 실패: ${result.message}"
    is SignUpResult.Processing -> "처리 중입니다"
}

println(message)

이 방식의 장점은 분명합니다.

  • 성공과 실패를 모두 “정상적인 값“으로 다룬다
  • 실패 이유를 함께 담아 전달할 수 있다
  • 모든 경우를 처리했는지 컴파일러가 검사해 준다

특히 마지막 장점이 큽니다.
호출한 쪽이 실패 처리를 깜빡하면
Exhaustive Check가 이를 잡아냅니다.

오류 코드와 결과 객체

실무에서는 실패 사유를
단순한 문자열보다 오류 코드로 다룰 때가 많습니다.

오류 코드(error code)란
“실패의 종류를 구분하는 정해진 값“입니다.

오류 코드야말로 Enum이 딱 맞는 자리입니다.

enum class ErrorCode(val message: String) {
    DUPLICATE_EMAIL("이미 사용 중인 이메일입니다"),
    INVALID_EMAIL("이메일 형식이 올바르지 않습니다"),
    SERVER_ERROR("잠시 후 다시 시도해 주세요")
}

이제 실패 결과 안에
오류 코드를 담을 수 있습니다.

sealed interface SignUpResult {
    data class Success(val userId: Long) : SignUpResult
    data class Failure(val code: ErrorCode) : SignUpResult
}

여기서 두 도구가
어떻게 역할을 나누는지 보입니다.

  • Sealed Type: 결과의 큰 갈래(성공 / 실패)
  • Enum: 실패 안에서의 세부 종류(오류 코드)

둘을 함께 쓰면 이렇게 깔끔해집니다.

fun signUp(email: String): SignUpResult {
    if (!email.contains("@")) {
        return SignUpResult.Failure(ErrorCode.INVALID_EMAIL)
    }
    return SignUpResult.Success(userId = 1001)
}

val result = signUp("wrong-email")

when (result) {
    is SignUpResult.Success ->
        println("가입 성공: ${result.userId}")
    is SignUpResult.Failure ->
        println("가입 실패: ${result.code.message}")
}
가입 실패: 이메일 형식이 올바르지 않습니다

이렇게 성공과 실패를 담아 전달하는 값을
결과 객체(result object)라고 부릅니다.

결과를 타입으로 표현하는 이 방식은
코틀린 백엔드에서 아주 자주 쓰입니다.

이 주제는 함수형 스타일의 오류 처리와도
이어지며, 24장에서 더 깊이 다룹니다.


10.5 Enum과 Sealed Type 선택하기

이제 정리해 봅시다.
Enum과 Sealed Type은 언제 골라야 할까요?

예제 세 가지로 비교해 보겠습니다.

예제 1: 주문 상태 → Enum

주문 상태를 다시 봅시다.

  • 결제 대기, 결제 완료, 배송 중, 배송 완료
  • 종류가 딱 정해져 있다
  • 각 상태가 담을 별도 데이터가 없다

이런 경우는 Enum이 정답입니다.

enum class OrderStatus {
    PENDING, PAID, SHIPPING, DELIVERED
}

모든 값이 같은 모양이고,
그저 “어떤 상태인가“만 구분하면 되기 때문입니다.

예제 2: 결제 결과 → Sealed Type

결제 결과는 조금 다릅니다.

  • 성공: 승인 번호가 필요
  • 실패: 실패 사유가 필요
  • 처리 중: 담을 데이터가 없음

종류마다 담는 데이터가 다릅니다.
이럴 때는 Sealed Type이 맞습니다.

sealed interface PaymentResult {
    data class Success(val approvalNo: String) : PaymentResult
    data class Failure(val reason: String) : PaymentResult
    object Pending : PaymentResult
}

예제 3: API 처리 결과 → 함께 쓰기

실제 서버의 API 응답은
두 도구를 섞어 쓰는 경우가 많습니다.

큰 갈래는 Sealed Type으로,
실패의 세부 종류는 Enum으로 나눕니다.

enum class ApiErrorCode(val message: String) {
    NOT_FOUND("대상을 찾을 수 없습니다"),
    UNAUTHORIZED("권한이 없습니다"),
    SERVER_ERROR("서버 오류가 발생했습니다")
}

sealed interface ApiResult {
    data class Ok(val body: String) : ApiResult
    data class Error(val code: ApiErrorCode) : ApiResult
}

큰 흐름(성공 / 실패)은 타입으로 나누고,
실패의 이유는 오류 코드로 세분화합니다.

두 도구는 경쟁 관계가 아니라
서로를 보완하는 사이입니다.

한눈에 보는 선택 기준

마지막으로 표로 정리하겠습니다.

질문그렇다면
값의 종류가 정해져 있는가둘 다 후보
각 값이 같은 모양인가Enum
종류마다 다른 데이터를 담는가Sealed Type
공통 데이터를 함께 저장하고 싶은가Sealed Class
그냥 종류만 나누면 되는가Sealed Interface
실패의 세부 사유를 구분해야 하는가Enum(오류 코드)

기억하기 쉽게 한 문장으로 줄이면 이렇습니다.

단순한 값 목록이면 Enum,
종류마다 데이터가 다르면 Sealed Type.


10장을 마치며

이 장에서 우리는 다음을 배웠습니다.

  • Enum은 “정해진 값들의 목록“을
    하나의 타입으로 만든 것이라는 점
  • Enum에 프로퍼티와 메서드를 붙여
    값과 정보와 동작을 한곳에 모을 수 있다는 점
  • Sealed Class는 “정해진 자식만 가지는
    봉인된 타입“이며, 종류마다 다른 데이터를 담는다는 점
  • when과 함께 쓰면 Exhaustive Check로
    빠뜨린 경우를 컴파일러가 잡아 준다는 점
  • Sealed Interface로 상태의 이름을 넘어
    타입 자체를 모델링할 수 있다는 점
  • 성공과 실패를 타입으로 표현하면
    결과를 더 안전하게 다룰 수 있다는 점

두 가지만 기억해도 충분합니다.
“단순한 값이면 Enum”,
그리고 “종류마다 다르면 Sealed Type“입니다.

다음 장에서는
지금까지 배운 것들을 바탕으로
한 걸음 더 나아가 봅니다.